summaryrefslogtreecommitdiff
path: root/app/[lng]/test/table-v2/page.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'app/[lng]/test/table-v2/page.tsx')
-rw-r--r--app/[lng]/test/table-v2/page.tsx630
1 files changed, 630 insertions, 0 deletions
diff --git a/app/[lng]/test/table-v2/page.tsx b/app/[lng]/test/table-v2/page.tsx
new file mode 100644
index 00000000..e7fb5bdd
--- /dev/null
+++ b/app/[lng]/test/table-v2/page.tsx
@@ -0,0 +1,630 @@
+"use client";
+
+import * as React from "react";
+import { PaginationState, SortingState, ColumnFiltersState, GroupingState } from "@tanstack/react-table";
+import { ClientVirtualTable } from "@/components/client-table-v2/client-virtual-table";
+import { TestProduct } from "@/db/schema/test-table-v2";
+import { productColumns, orderColumns } from "./columns";
+import { OrderWithDetails } from "./column-defs";
+import {
+ getAllProducts,
+ getProductTableData,
+ getOrderTableData,
+ getProductTableDataWithGrouping,
+ GroupInfo,
+} from "./actions";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { ChevronDown, ChevronRight, Loader2 } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+// ============================================================
+// Reusable Loading Overlay Component
+// ============================================================
+
+function LoadingOverlay({
+ isLoading,
+ children
+}: {
+ isLoading: boolean;
+ children: React.ReactNode
+}) {
+ return (
+ <div className="relative">
+ {children}
+ {isLoading && (
+ <div className="absolute inset-0 z-50 flex items-center justify-center bg-background/60 backdrop-blur-[2px] transition-all duration-200">
+ <div className="flex items-center gap-2 px-4 py-2 bg-background rounded-lg shadow-lg border">
+ <Loader2 className="h-5 w-5 animate-spin text-primary" />
+ <span className="text-sm text-muted-foreground">Loading...</span>
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
+
+// ============================================================
+// Pattern 1: Client-Side Table
+// ============================================================
+
+function ClientSideTable() {
+ const [data, setData] = React.useState<TestProduct[]>([]);
+ const [isLoading, setIsLoading] = React.useState(true);
+
+ React.useEffect(() => {
+ const fetchData = async () => {
+ setIsLoading(true);
+ try {
+ const products = await getAllProducts();
+ setData(products);
+ } catch (error) {
+ console.error("Failed to fetch products:", error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchData();
+ }, []);
+
+ return (
+ <Card>
+ <CardHeader>
+ <div className="flex items-center gap-2">
+ <CardTitle>Pattern 1: Client-Side</CardTitle>
+ <Badge variant="outline">fetchMode=&quot;client&quot;</Badge>
+ </div>
+ <CardDescription>
+ 모든 데이터를 한 번에 받아와 클라이언트에서 필터링/정렬/페이지네이션/그룹핑 처리합니다.
+ <br />
+ <span className="text-muted-foreground">
+ 적합: 데이터 1000건 이하, 빠른 인터랙션 필요 시
+ </span>
+ <br />
+ <span className="text-emerald-600 text-sm">
+ ✅ 그룹핑: 헤더 우클릭 → Group by [Column]
+ </span>
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <LoadingOverlay isLoading={isLoading}>
+ <div className="h-[500px]">
+ <ClientVirtualTable
+ fetchMode="client"
+ data={data}
+ columns={productColumns}
+ isLoading={false} // LoadingOverlay로 처리
+ enablePagination
+ enableGrouping
+ height="100%"
+ />
+ </div>
+ </LoadingOverlay>
+ </CardContent>
+ </Card>
+ );
+}
+
+// ============================================================
+// Pattern 2: Factory Service (Server-Side)
+// ============================================================
+
+function FactoryServiceTable() {
+ const [data, setData] = React.useState<TestProduct[]>([]);
+ const [totalRows, setTotalRows] = React.useState(0);
+ const [isLoading, setIsLoading] = React.useState(true);
+
+ // Table state
+ const [pagination, setPagination] = React.useState<PaginationState>({
+ pageIndex: 0,
+ pageSize: 10,
+ });
+ const [sorting, setSorting] = React.useState<SortingState>([]);
+ const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
+ const [globalFilter, setGlobalFilter] = React.useState("");
+
+ // Fetch data on state change
+ React.useEffect(() => {
+ const fetchData = async () => {
+ setIsLoading(true);
+ try {
+ const result = await getProductTableData({
+ pagination,
+ sorting,
+ columnFilters,
+ globalFilter,
+ });
+ setData(result.data);
+ setTotalRows(result.totalRows);
+ } catch (error) {
+ console.error("Failed to fetch products:", error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchData();
+ }, [pagination, sorting, columnFilters, globalFilter]);
+
+ return (
+ <Card>
+ <CardHeader>
+ <div className="flex items-center gap-2">
+ <CardTitle>Pattern 2: Factory Service</CardTitle>
+ <Badge variant="outline">fetchMode=&quot;server&quot;</Badge>
+ <Badge variant="secondary">createTableService</Badge>
+ </div>
+ <CardDescription>
+ <code>createTableService</code>로 서버 액션을 자동 생성합니다.
+ <br />
+ <span className="text-muted-foreground">
+ 적합: 단순 CRUD, 마스터 테이블 조회
+ </span>
+ <br />
+ <span className="text-amber-600 text-sm">
+ ⚠️ 그룹핑: 서버 모드에서는 별도 구현 필요 (Pattern 2-B 참고)
+ </span>
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <LoadingOverlay isLoading={isLoading}>
+ <div className="h-[500px]">
+ <ClientVirtualTable
+ fetchMode="server"
+ data={data}
+ rowCount={totalRows}
+ columns={productColumns}
+ isLoading={false}
+ enablePagination
+ enableGrouping={false}
+ height="100%"
+ pagination={pagination}
+ onPaginationChange={setPagination}
+ sorting={sorting}
+ onSortingChange={setSorting}
+ columnFilters={columnFilters}
+ onColumnFiltersChange={setColumnFilters}
+ globalFilter={globalFilter}
+ onGlobalFilterChange={setGlobalFilter}
+ />
+ </div>
+ </LoadingOverlay>
+ </CardContent>
+ </Card>
+ );
+}
+
+// ============================================================
+// Pattern 2-B: Server-Side Grouping (Context Menu 방식)
+// ============================================================
+
+function ServerGroupingTable() {
+ const [grouping, setGrouping] = React.useState<GroupingState>([]);
+ const [expandedGroups, setExpandedGroups] = React.useState<string[]>([]);
+ const [groups, setGroups] = React.useState<GroupInfo[]>([]);
+ const [flatData, setFlatData] = React.useState<TestProduct[]>([]);
+ const [isGrouped, setIsGrouped] = React.useState(false);
+ const [isLoading, setIsLoading] = React.useState(true);
+ const [totalRows, setTotalRows] = React.useState(0);
+
+ const [pagination, setPagination] = React.useState<PaginationState>({
+ pageIndex: 0,
+ pageSize: 10,
+ });
+
+ // 데이터 페칭
+ React.useEffect(() => {
+ const fetchData = async () => {
+ setIsLoading(true);
+ try {
+ const result = await getProductTableDataWithGrouping(
+ { pagination, grouping },
+ expandedGroups
+ );
+
+ if ('groups' in result) {
+ setGroups(result.groups);
+ setIsGrouped(true);
+ setFlatData([]);
+ } else {
+ setFlatData(result.data);
+ setTotalRows(result.totalRows);
+ setIsGrouped(false);
+ setGroups([]);
+ }
+ } catch (error) {
+ console.error("Failed to fetch:", error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchData();
+ }, [pagination, grouping, expandedGroups]);
+
+ // 그룹 토글
+ const toggleGroup = (groupKey: string) => {
+ setExpandedGroups(prev =>
+ prev.includes(groupKey)
+ ? prev.filter(k => k !== groupKey)
+ : [...prev, groupKey]
+ );
+ };
+
+ // 그룹핑 상태 변경 핸들러 (Context Menu에서 호출됨)
+ const handleGroupingChange = React.useCallback((updater: GroupingState | ((old: GroupingState) => GroupingState)) => {
+ const newGrouping = typeof updater === 'function' ? updater(grouping) : updater;
+ setGrouping(newGrouping);
+ setExpandedGroups([]); // 그룹핑 변경 시 확장 상태 초기화
+ }, [grouping]);
+
+ return (
+ <Card>
+ <CardHeader>
+ <div className="flex items-center gap-2">
+ <CardTitle>Pattern 2-B: Server-Side Grouping</CardTitle>
+ <Badge variant="outline">fetchMode=&quot;server&quot;</Badge>
+ <Badge className="bg-emerald-500">GROUP BY</Badge>
+ </div>
+ <CardDescription>
+ 서버에서 GROUP BY + 집계 쿼리로 그룹 정보를 조회합니다.
+ <br />
+ <span className="text-emerald-600 text-sm">
+ ✅ 그룹핑: 헤더 우클릭 → Group by [Column] (category, status, isNew만 지원)
+ </span>
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {/* 현재 그룹핑 상태 표시 */}
+ {grouping.length > 0 && (
+ <div className="flex items-center gap-2 text-sm">
+ <span className="text-muted-foreground">Grouped by:</span>
+ {grouping.map((col) => (
+ <Badge key={col} variant="secondary">
+ {col}
+ <button
+ className="ml-1 hover:text-destructive"
+ onClick={() => setGrouping([])}
+ >
+ ×
+ </button>
+ </Badge>
+ ))}
+ </div>
+ )}
+
+ {/* Content with Loading Overlay */}
+ <LoadingOverlay isLoading={isLoading}>
+ <div className="border rounded-md min-h-[400px] max-h-[500px] overflow-auto">
+ {isGrouped ? (
+ // Grouped View - Custom Rendering
+ <div className="divide-y">
+ {groups.length === 0 ? (
+ <div className="flex items-center justify-center h-[400px] text-muted-foreground">
+ No data
+ </div>
+ ) : (
+ groups.map((group) => (
+ <div key={group.groupKey}>
+ {/* Group Header */}
+ <button
+ className="w-full px-4 py-3 flex items-center gap-3 hover:bg-muted/50 transition-colors text-left"
+ onClick={() => toggleGroup(group.groupKey)}
+ >
+ {expandedGroups.includes(group.groupKey) ? (
+ <ChevronDown className="w-4 h-4" />
+ ) : (
+ <ChevronRight className="w-4 h-4" />
+ )}
+ <span className="font-medium">
+ {grouping[0]}: <Badge variant="outline">{String(group.groupValue)}</Badge>
+ </span>
+ <span className="text-muted-foreground text-sm">
+ ({group.count} items)
+ </span>
+ </button>
+
+ {/* Expanded Rows */}
+ {expandedGroups.includes(group.groupKey) && group.rows && (
+ <div className="bg-muted/20 border-t">
+ <table className="w-full text-sm">
+ <thead>
+ <tr className="border-b bg-muted/30">
+ <th className="px-4 py-2 text-left">ID</th>
+ <th className="px-4 py-2 text-left">SKU</th>
+ <th className="px-4 py-2 text-left">Name</th>
+ <th className="px-4 py-2 text-left">Price</th>
+ <th className="px-4 py-2 text-left">Stock</th>
+ </tr>
+ </thead>
+ <tbody>
+ {group.rows.map((row) => (
+ <tr key={row.id} className="border-b hover:bg-muted/30">
+ <td className="px-4 py-2">{row.id}</td>
+ <td className="px-4 py-2 font-mono text-xs">{row.sku}</td>
+ <td className="px-4 py-2">{row.name}</td>
+ <td className="px-4 py-2">
+ {new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ }).format(parseFloat(row.price))}
+ </td>
+ <td className="px-4 py-2">{row.stock}</td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ )}
+ </div>
+ ))
+ )}
+ </div>
+ ) : (
+ // Normal Table View with Context Menu Grouping
+ <ClientVirtualTable
+ fetchMode="server"
+ data={flatData}
+ rowCount={totalRows}
+ columns={productColumns}
+ enablePagination
+ enableGrouping // Context Menu에서 Group By 옵션 활성화
+ height="400px"
+ pagination={pagination}
+ onPaginationChange={setPagination}
+ // 그룹핑 상태 연결
+ grouping={grouping}
+ onGroupingChange={handleGroupingChange}
+ />
+ )}
+ </div>
+ </LoadingOverlay>
+ </CardContent>
+ </Card>
+ );
+}
+
+// ============================================================
+// Pattern 3: Custom Service (Server-Side with Joins)
+// ============================================================
+
+function CustomServiceTable() {
+ const [data, setData] = React.useState<OrderWithDetails[]>([]);
+ const [totalRows, setTotalRows] = React.useState(0);
+ const [isLoading, setIsLoading] = React.useState(true);
+
+ // Table state
+ const [pagination, setPagination] = React.useState<PaginationState>({
+ pageIndex: 0,
+ pageSize: 10,
+ });
+ const [sorting, setSorting] = React.useState<SortingState>([]);
+ const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
+ const [globalFilter, setGlobalFilter] = React.useState("");
+
+ // Fetch data on state change
+ React.useEffect(() => {
+ const fetchData = async () => {
+ setIsLoading(true);
+ try {
+ const result = await getOrderTableData({
+ pagination,
+ sorting,
+ columnFilters,
+ globalFilter,
+ });
+ setData(result.data);
+ setTotalRows(result.totalRows);
+ } catch (error) {
+ console.error("Failed to fetch orders:", error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchData();
+ }, [pagination, sorting, columnFilters, globalFilter]);
+
+ return (
+ <Card>
+ <CardHeader>
+ <div className="flex items-center gap-2">
+ <CardTitle>Pattern 3: Custom Service</CardTitle>
+ <Badge variant="outline">fetchMode=&quot;server&quot;</Badge>
+ <Badge variant="secondary">DrizzleTableAdapter</Badge>
+ </div>
+ <CardDescription>
+ <code>DrizzleTableAdapter</code>를 도구로 사용하여 복잡한 조인 쿼리를 직접 작성합니다.
+ <br />
+ <span className="text-muted-foreground">
+ 적합: 여러 테이블 조인, 복잡한 비즈니스 로직
+ </span>
+ <br />
+ <span className="text-amber-600 text-sm">
+ ⚠️ 그룹핑: 가상 컬럼(조인 결과)은 서버 GROUP BY 불가
+ </span>
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <LoadingOverlay isLoading={isLoading}>
+ <div className="h-[500px]">
+ <ClientVirtualTable
+ fetchMode="server"
+ data={data}
+ rowCount={totalRows}
+ columns={orderColumns}
+ isLoading={false}
+ enablePagination
+ enableGrouping={false}
+ height="100%"
+ pagination={pagination}
+ onPaginationChange={setPagination}
+ sorting={sorting}
+ onSortingChange={setSorting}
+ columnFilters={columnFilters}
+ onColumnFiltersChange={setColumnFilters}
+ globalFilter={globalFilter}
+ onGlobalFilterChange={setGlobalFilter}
+ />
+ </div>
+ </LoadingOverlay>
+ </CardContent>
+ </Card>
+ );
+}
+
+// ============================================================
+// Main Page
+// ============================================================
+
+export default function TableV2TestPage() {
+ return (
+ <div className="container py-6 space-y-6">
+ <div>
+ <h1 className="text-3xl font-bold tracking-tight">
+ ClientVirtualTable V2 - 데이터 페칭 패턴 테스트
+ </h1>
+ <p className="text-muted-foreground mt-2">
+ GUIDE.md에 정의된 데이터 페칭 패턴과 그룹핑 처리 방법을 테스트합니다.
+ <br />
+ 테스트 전 시딩이 필요합니다: <code className="bg-muted px-1 rounded">npx tsx db/seeds/test-table-v2.ts</code>
+ </p>
+ </div>
+
+ <Tabs defaultValue="pattern1" className="space-y-4">
+ <TabsList className="grid w-full grid-cols-4">
+ <TabsTrigger value="pattern1">
+ 1. Client-Side
+ </TabsTrigger>
+ <TabsTrigger value="pattern2">
+ 2. Factory Service
+ </TabsTrigger>
+ <TabsTrigger value="pattern2b">
+ 2-B. Server Grouping
+ </TabsTrigger>
+ <TabsTrigger value="pattern3">
+ 3. Custom Service
+ </TabsTrigger>
+ </TabsList>
+
+ <TabsContent value="pattern1">
+ <ClientSideTable />
+ </TabsContent>
+
+ <TabsContent value="pattern2">
+ <FactoryServiceTable />
+ </TabsContent>
+
+ <TabsContent value="pattern2b">
+ <ServerGroupingTable />
+ </TabsContent>
+
+ <TabsContent value="pattern3">
+ <CustomServiceTable />
+ </TabsContent>
+ </Tabs>
+
+ {/* Summary Table */}
+ <Card>
+ <CardHeader>
+ <CardTitle>패턴별 그룹핑 지원 현황</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="overflow-x-auto">
+ <table className="w-full text-sm">
+ <thead>
+ <tr className="border-b">
+ <th className="text-left py-2 px-4">패턴</th>
+ <th className="text-left py-2 px-4">그룹핑 방식</th>
+ <th className="text-left py-2 px-4">가상 컬럼 지원</th>
+ <th className="text-left py-2 px-4">비고</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr className="border-b">
+ <td className="py-2 px-4 font-medium">1. Client-Side</td>
+ <td className="py-2 px-4">
+ <Badge className="bg-emerald-500">TanStack Grouping</Badge>
+ </td>
+ <td className="py-2 px-4">
+ <Badge className="bg-emerald-500">✓ 지원</Badge>
+ </td>
+ <td className="py-2 px-4 text-muted-foreground">
+ 메모리에서 처리, 전체 데이터 필요
+ </td>
+ </tr>
+ <tr className="border-b">
+ <td className="py-2 px-4 font-medium">2. Factory Service</td>
+ <td className="py-2 px-4">
+ <Badge variant="outline">미지원</Badge>
+ </td>
+ <td className="py-2 px-4">-</td>
+ <td className="py-2 px-4 text-muted-foreground">
+ 별도 구현 필요 (2-B 참고)
+ </td>
+ </tr>
+ <tr className="border-b">
+ <td className="py-2 px-4 font-medium">2-B. Server Grouping</td>
+ <td className="py-2 px-4">
+ <Badge className="bg-blue-500">DB GROUP BY</Badge>
+ </td>
+ <td className="py-2 px-4">
+ <Badge variant="destructive">✗ 불가</Badge>
+ </td>
+ <td className="py-2 px-4 text-muted-foreground">
+ serverGroupable 컬럼만 가능
+ </td>
+ </tr>
+ <tr>
+ <td className="py-2 px-4 font-medium">3. Custom Service</td>
+ <td className="py-2 px-4">
+ <Badge variant="secondary">커스텀 구현</Badge>
+ </td>
+ <td className="py-2 px-4">
+ <Badge variant="secondary">선택적</Badge>
+ </td>
+ <td className="py-2 px-4 text-muted-foreground">
+ 쿼리 설계에 따라 다름
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* Column Groupability Info */}
+ <Card>
+ <CardHeader>
+ <CardTitle>컬럼별 서버 그룹핑 지원 여부</CardTitle>
+ <CardDescription>
+ <code>meta.serverGroupable</code> 플래그로 DB GROUP BY 가능 여부를 표시합니다.
+ <br />
+ 헤더 우클릭 시 &quot;Group by [Column]&quot; 메뉴가 표시됩니다.
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <div className="flex flex-wrap gap-2">
+ {productColumns.map((col) => {
+ if (!('accessorKey' in col)) return null;
+ const meta = col.meta as { serverGroupable?: boolean } | undefined;
+ const isGroupable = meta?.serverGroupable;
+ return (
+ <Badge
+ key={col.accessorKey as string}
+ variant={isGroupable ? "default" : "outline"}
+ className={isGroupable ? "bg-emerald-500" : ""}
+ >
+ {col.accessorKey as string}
+ {isGroupable && " ✓"}
+ </Badge>
+ );
+ })}
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ );
+}